import fs from "node:fs"; import fsp from "node:fs/promises"; import path from "node:path"; import { Readable } from "node:stream"; import { getSession } from "@/lib/auth/session"; import { canAccessBranch } from "@/lib/auth/permissions"; import { withErrorHandling, badRequest, unauthorized, forbidden, notFound, ApiError, } from "@/lib/api/errors"; import { mapStorageReadError } from "@/lib/api/storageErrors"; export const dynamic = "force-dynamic"; export const runtime = "nodejs"; const BRANCH_RE = /^NL\d+$/; const YEAR_RE = /^\d{4}$/; const MONTH_RE = /^(0[1-9]|1[0-2])$/; const DAY_RE = /^(0[1-9]|[12]\d|3[01])$/; function getNasRootOrThrow() { const root = process.env.NAS_ROOT_PATH; if (!root) { throw new ApiError({ status: 500, code: "FS_STORAGE_ERROR", message: "Internal server error", }); } return root; } function isSafeFilename(name) { if (typeof name !== "string") return false; const trimmed = name.trim(); if (!trimmed) return false; // Reject special path segments if (trimmed === "." || trimmed === "..") return false; // Reject any path separators (defense-in-depth) if (trimmed.includes("/") || trimmed.includes("\\")) return false; // Reject control chars (header injection) if (/[\r\n\t]/.test(trimmed)) return false; // Reject quotes to keep Content-Disposition predictable/safe if (trimmed.includes('"')) return false; // Ensure it's a basename (no sneaky segments) if (path.basename(trimmed) !== trimmed) return false; return true; } function isPdfFilename(name) { return typeof name === "string" && name.toLowerCase().endsWith(".pdf"); } function validateParamsOrThrow({ branch, year, month, day, filename }) { if (!BRANCH_RE.test(branch)) { throw badRequest("VALIDATION_BRANCH", "Invalid branch parameter", { branch, }); } if (!YEAR_RE.test(year)) { throw badRequest("VALIDATION_YEAR", "Invalid year parameter", { year }); } if (!MONTH_RE.test(month)) { throw badRequest("VALIDATION_MONTH", "Invalid month parameter", { month }); } if (!DAY_RE.test(day)) { throw badRequest("VALIDATION_DAY", "Invalid day parameter", { day }); } if (!isSafeFilename(filename)) { throw badRequest("VALIDATION_FILENAME", "Invalid filename parameter", { filename, }); } if (!isPdfFilename(filename)) { throw badRequest( "VALIDATION_FILE_EXTENSION", "Only PDF files are allowed", { filename } ); } } function resolvePdfPathOrThrow({ root, branch, year, month, day, filename }) { const rootAbs = path.resolve(root); const absPath = path.resolve(rootAbs, branch, year, month, day, filename); // Ensure the resolved path stays within NAS_ROOT_PATH const rel = path.relative(rootAbs, absPath); if (rel.startsWith("..") || path.isAbsolute(rel)) { throw badRequest("VALIDATION_PATH_TRAVERSAL", "Invalid file path", { branch, year, month, day, filename, }); } return absPath; } /** * Content-Disposition helper (Unicode-safe). * * Problem: * - Node's Web Response headers require ByteString-compatible values. * - Unicode characters (e.g. "€") in `filename="..."` can crash the response creation. * * Solution: * - Provide an ASCII fallback via `filename="..."`. * - Provide the real UTF-8 name via RFC 5987: `filename*=UTF-8''...`. */ function stripDiacritics(input) { return String(input) .normalize("NFKD") .replace(/[\u0300-\u036f]/g, ""); } function toAsciiFallbackFilename(filename) { // Keep it predictable and safe for headers: ASCII only. // We also keep the .pdf extension if possible. const raw = stripDiacritics(filename); const ascii = raw .replace(/[^\x20-\x7E]/g, "_") // replace non-ASCII with underscore .replace(/\s+/g, " ") // collapse whitespace .replace(/_+/g, "_") // collapse underscores .trim(); if (!ascii) return "download.pdf"; if (!ascii.toLowerCase().endsWith(".pdf")) return `${ascii}.pdf`; return ascii; } function encodeRFC5987ValueChars(str) { // RFC 5987 encoding for header parameters: // Use percent-encoded UTF-8 bytes and additionally encode a few chars that // encodeURIComponent leaves as-is but can be problematic in headers. return encodeURIComponent(str) .replace(/['()]/g, (c) => `%${c.charCodeAt(0).toString(16).toUpperCase()}`) .replace(/\*/g, "%2A"); } function buildContentDisposition(filename, asAttachment) { const type = asAttachment ? "attachment" : "inline"; const fallback = toAsciiFallbackFilename(filename); const encoded = encodeRFC5987ValueChars(filename); return `${type}; filename="${fallback}"; filename*=UTF-8''${encoded}`; } /** * GET /api/files/:branch/:year/:month/:day/:filename * * Query (optional): * - download=1 | download=true => Content-Disposition: attachment * - default => inline */ export const GET = withErrorHandling( async function GET(request, ctx) { const session = await getSession(); if (!session) { throw unauthorized("AUTH_UNAUTHENTICATED", "Unauthorized"); } const { branch, year, month, day, filename } = await ctx.params; const missing = []; if (!branch) missing.push("branch"); if (!year) missing.push("year"); if (!month) missing.push("month"); if (!day) missing.push("day"); if (!filename) missing.push("filename"); if (missing.length > 0) { throw badRequest( "VALIDATION_MISSING_PARAM", "Missing required route parameter(s)", { params: missing } ); } if (!canAccessBranch(session, branch)) { throw forbidden("AUTH_FORBIDDEN_BRANCH", "Forbidden"); } validateParamsOrThrow({ branch, year, month, day, filename }); const root = getNasRootOrThrow(); const absPath = resolvePdfPathOrThrow({ root, branch, year, month, day, filename, }); const details = { branch, year, month, day, filename }; let stat; try { stat = await fsp.stat(absPath); } catch (err) { throw await mapStorageReadError(err, { details }); } if (!stat.isFile()) { throw notFound("FS_NOT_FOUND", "Not found", details); } const { searchParams } = new URL(request.url); const download = (searchParams.get("download") || "").toLowerCase(); const asAttachment = download === "1" || download === "true"; const contentDisposition = buildContentDisposition(filename, asAttachment); const nodeStream = fs.createReadStream(absPath); const webStream = Readable.toWeb(nodeStream); return new Response(webStream, { status: 200, headers: { "Content-Type": "application/pdf", "Content-Disposition": contentDisposition, "Content-Length": String(stat.size), "Cache-Control": "no-store", "X-Content-Type-Options": "nosniff", }, }); }, { logPrefix: "[api/files/[branch]/[year]/[month]/[day]/[filename]]" } );